iT邦幫忙

2023 iThome 鐵人賽

DAY 13
0
Vue.js

Nuxt 3 實戰筆記系列 第 13

[Day 13] Nuxt 3 使用路由中間件(Middleware)實作身份驗證與實作技巧

  • 分享至 

  • xImage
  •  

前言

Nuxt 3 提供了一個路由中間件的框架,我們只要再專案目錄 middleware 下定義好路由中間件,就可以在路由切換之間來實現一些功能,包含了身份驗證、重定向、添加資料或分析等等操作。

路由中間件的使用,可以參考這一篇文章「Nuxt 3 中間件目錄 (Middleware Directory)」,本篇將會著重在實作路由中間件的部分。

身份驗證(Authentication

在網站開發中,會員系統是一個常見的功能,然而在某些頁面,你可能會期望是僅有登入的會員或特定權限才能進行瀏覽,這項功能或邏輯的實作可為身份驗證或路由守衛等方式。

基本上核心概念就是在頁面與頁面切換之間,如首頁跳轉到後台管理頁面,經過一個檢查員進行判斷,這個檢查員即是路由中間件,我們會在這個中間件去實作如何判斷是否登入、是否擁有權限,最後再決定是否放行繼續瀏覽或者導向回登入頁或其他頁面。

你可以很快速的建立一個檔案 ./middleware/auth.js,來檢查是否登入狀態 isLoggedIn,並決定要不做任何事繼續放行還是導向至登入頁面:

export default defineNuxtRouteMiddleware((to, from) => {
  const isLoggedIn = useState('isLoggedIn', () => false);

  if (isLoggedIn.value) {
    return
  }

  return navigateTo('/login')
})

你可以在每個頁面使用 definePageMeta 函式,來套用這個 auth 的中間件,也可以選擇將這個中間件定義為全域的來表示整個網站都需要登入才能瀏覽。

// 需要驗證的頁面,例如 ./pages/profile.vue

<script setup>
definePageMeta({
  middleware: ['auth']
})
</script>

以上述例子,登入狀態 isLoggedIn 定義的非常簡單,但實務情況可能會稍不一樣,本篇將舉幾個例子來分享可以怎麼實作更具實用的身份驗證。

使用 Token、Cookie 或 Session

當使用者成功登入後,網站系統通常會回傳一組登入憑證,並保存於瀏覽器之中,以便下次使用者操作網頁時,會再次夾帶這組憑證給網站伺服器進行辨識,以表示不同使用者的操作。

各個網站時做的方向都不會差異太多,由其在 Nuxt 這類 SSR 的框架,多數會仰賴由瀏覽器自動夾帶的機制,以確保首次的請求,就足以辨識身份來渲染出客製化的資料,使用瀏覽器的 Cookie 就會是個不錯的選擇。

Token、Cookie 或 Session,多數是以一串雜湊或亂數字串的方式表示,使其不容易被推敲及偽造,這組由網站伺服器登入成功後,所回傳的字串,會依據每個網站的實作而保存在不同地方,常見的有 Cookie、Local Storage 等。

不管存放在何處及其稱之為哪一個術語,只要是用來識別使用者的,我們都可以稱之為使用者的憑證,這個使用者憑證,正足以讓我們來辨識使用者是否已經登入。

檢查憑證是否存在

當瀏覽器已經保存使用者的憑證,例如是一串 nuxt3_user_token 這樣子的亂數字串。

如果憑證是放置在 Cookie 的 session_token 欄位中,你可以使用 useCookie 函式,將 session_token 的值取出,並判斷是否有值表示使用者是否已經登入。

export default defineNuxtRouteMiddleware((to, from) => {
  const isLoggedIn = useState('isLoggedIn', () => false);

  if (process.server) {
    const sessionToken = useCookie('session_token')

    if (sessionToken.value !== undefined) {
      isLoggedIn.value = true
    }
  }

  if (isLoggedIn.value) {
    return
  }

  return navigateTo('/login')
})

上例子的程式碼,可以發現在使用 useCookie 函式,有一個判斷式 process.server,來確保這段取 Cookie 的邏輯是在伺服器端執行,主要是因為 Nuxt 框架預設使用混合渲染,第一次進入網站是伺服器端渲染(SSR)後續的路由切換,則是客戶端渲染(CSR),所以當使用者已經在 CSR 狀態下進行切換,可能會因為 Cookie 不會自動被夾帶在請求中導致無法取值。

當然你可能會想,我可以使用 JS 的操作 Cookie 方法來實作 Client 的判斷邏輯,但你可能還需要考量到,該 Cookie 欄位是否被設置為 HttpOnly,才能放心的在客戶端直接以 JS 操作 Cookie。

因此,上述的程式碼,僅能在首次進入頁面時進行正確的登入判斷,如果使用者是被導向到登入頁面,登入後切換頁面,那麼這個中間件等於沒有價值了。

此外,單純的判斷 Cookie 是否有值是不足以證明使用者真的已經登入,因為 Cookie 也是可以被偽造的,所以就算使用者偽造或亂塞數值,導致網頁可以被瀏覽,但一些相關操作一定要在後端與 API 進行判斷與保護。

檢查憑證是否正確

我們可以稍微改寫一下程式碼,讓 Cookie 可以被驗證。

首先,我們建立一個組合式函式 ./composables/session.js,其中提供一個函式 check,將 Cookie 夾帶至 /api/user 來取得使用者資訊,並判斷是否有使用者編號 user.id

export const useUserSession = () => {
  return {
    check
  }
}

async function check() {
  const userInfo = await $fetch('/api/user', {
    method: 'GET',
    headers: useRequestHeaders(['cookie'])
  })

  return userInfo?.id !== undefined
}

/api/user 的 API 邏輯這邊就不贅述,在這支 API 你可以使用取得的 session_token 至資料庫或是快取中比對,如果存在及有效就回傳使用者的資訊。如此我們的中間件,就可以改寫成如下:

export default defineNuxtRouteMiddleware(async (to, from) => {
  const userSession = useUserSession()
  const isLoggedIn = await userSession.check();

  if (isLoggedIn) {
    return
  }

  return navigateTo('/login')
})

我們不在需要判斷處於伺服器或客戶端,每次路由中間件觸發,我都會依據 Cookie 來檢查使用者,如果存在及有效就可以繼續瀏覽後續的網頁,否則將導向登入頁面。

注意 Hydration

前一段範例這樣做會有什麼問題嗎?看似很合理的邏輯,但如果你的專案使用的是混合渲染模式,因為 Hydration 的關係,首次請求伺服器端會打一次 API,渲染完畢在客戶端 Hydration 結束後又會在打一次 API。

所以記得要在中間件加上相關判斷,判斷當程式在客戶端與 nuxtApp.isHydrating (Hydration 階段) 等,不執行中間件的邏輯:

export default defineNuxtRouteMiddleware(async (to, from) => {
  const nuxtApp = useNuxtApp()
  if (process.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) {
    return
  }

  const userSession = useUserSession()
  const isLoggedIn = await userSession.check();

  if (isLoggedIn) {
    return
  }

  return navigateTo('/login')
})

最後小提醒,因為每次切換頁面都會打 API 進行檢查,那麼伺服器的負擔便會增加,因為每次請求都會去資料庫檢查並判斷,理想情況是添加上快取或使用 JWT 之類的驗證方式來減少資料庫的對檔案系統的 I/O。

當然如果真的有必要每次都需要判斷使用者最新最正確的狀態,這樣寫是完全沒有問題的!

搭配狀態管理 (State Management) 與 Pinia

這裡我們使用 Pinia 來做 Nuxt3 的狀態管理,並將使用者資訊保存在 Store 之中,首先建立 ./stores/user.js 檔案:

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    info: {
      id: null,
      name: null,
      role: null,
    }
  }),
  actions: {
    async refreshUserInfo(info) {
      await $fetch('/api/user', {
        method: 'GET',
        headers: useRequestHeaders(['cookie'])
      }).then(userInfo => {
        this.info = userInfo
      })
    },
  },
  getters: {
    check: state => state.info.id !== null,
  },
})

修改中間件 ./middleware/auth.js

import { useUserStore } from '@/store/user'

export default defineNuxtRouteMiddleware(async (to, from) => {
  const userStore = useUserStore();

  if (userStore.check === false) {
    await userStore.refreshUserInfo();
  } 

  if (userStore.check) {
    return
  }

  return navigateTo('/login')
})

這樣,當首次進入網站,因為 store 尚未初始化,所以會執行函式 userStore.refreshUserInfo() 來刷新使用者資料,如果憑證正確也獲得了使用者 Id,那麼我們的 userStore.check 也會因此為 true,以此我們就可以來控制刷新使用者資訊的時機點,並透過判斷保存的使用者資訊來檢查是否有登入。

後續的憑證過期檢查與刪除使用者資訊或登出流程,再請讀者實作練習看看囉!

中間件的執行順序

全域中間件執行完畢後,再依據頁面中的定義中間件的陣列內名稱順序執行。

middleware
├── analytics.global.js
├── etup.global.js
└── auth.js
<script setup>
definePageMeta({
  middleware: [
    function (to, from) {
      // 匿名中間件實作
    },
    'auth',
  ],
});
</script>

中間件執行的順序如下:

  1. analytics.global.js
  2. setup.global.js
  3. 匿名中間件
  4. auth.js

如果你想要自訂全域中間件的執行順序,你可以為全域中間件的檔案名稱加上數字或字母字串來編號,預設情況全域中間間的執行順序,是依據字母的順序來 (alphabetical) 執行。

middleware
├── 01.analytics.global.js
├── 02.setup.global.js
└── auth.js

這邊要注意的是,排序是依據字串,而不是數值,所以 10.new.global.js 是會出現在 2.new.global.js 之前,這也就是為什麼上面的例子檔名要加上前綴 0

小結

當我們在撰寫路由中間件的過程中,不論是身份驗證或搭配後端 API 等非同步請求,一定要特別小心 Hydration,雖然可能不影響前端的操作,但後端伺服器可能會因此哭哭哦!

操作使用者登入與憑證的保存,除了在後端回傳 Cookie 時設定 HttpOnly 讓 JavaScript 無法直接存取,但也可能存在惡意攻擊者使用跨站請求偽造 (Cross Site Request Forgery, CSRF) 來偽造使用者請求;而有些網站會將使用者保存在瀏覽器的 Local Storage 或 Session Storage 之中,也要注意跨網站指令碼 (Cross-site scripting, XSS) 的弱點,不過這些功能與特性與 Cookie 搭配得宜,仍是一個保存使用者資訊的選擇。


感謝大家的閱讀,歡迎大家給予建議與討論,也請各位大大鞭小力一些:)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。

參考資料


上一篇
[Day 12] Nuxt 3 使用插件 (Plugin) 整合載入第三方套件 - 導入支援 Vue 3 的套件或插件
下一篇
[Day 14] Nuxt 3 最佳化圖片 動態調整請求控制圖片大小 - Nuxt Image
系列文
Nuxt 3 實戰筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言